--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 57605666811c38a6ee88f4a424b028a1f7962eea
Parents : 6956f2e
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-06-19T11:00:15-05:00
feat(tests): add comprehensive tests for canceling outbound messages and downloading file attachments, ensuring proper functionality in various scenarios
Changes
5 files changed, 532 insertions(+), 0 deletions(-)
Diff
diff --git a/tests/backend/test_lxmf_cancel_api.py b/tests/backend/test_lxmf_cancel_api.py
new file mode 100644
index 00000000..6e4fde81
--- /dev/null
+++ b/tests/backend/test_lxmf_cancel_api.py
@@ -0,0 +1,72 @@
+# SPDX-License-Identifier: 0BSD
+
+"""HTTP contract: POST /api/v1/lxmf-messages/{hash}/cancel must call router cancel."""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from aiohttp import web
+from aiohttp.test_utils import TestClient, TestServer
+
+
+def _build_aio_app(app):
+ routes = web.RouteTableDef()
+ auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
+ aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
+ aio_app.add_routes(routes)
+ return aio_app
+
+
+@pytest.fixture
+def web_cancel_app(mock_app):
+ mock_app.current_context.running = True
+ mock_app.config.auth_enabled.set(False)
+ mock_app.message_router = MagicMock()
+ mock_app.database.messages = MagicMock()
+ mock_app.database.messages.get_lxmf_message_by_hash.return_value = {
+ "hash": "aa" * 16,
+ "state": "cancelled",
+ }
+ with patch(
+ "meshchatx.meshchat.convert_db_lxmf_message_to_dict",
+ side_effect=lambda row: row,
+ ):
+ yield mock_app
+
+
+@pytest.mark.asyncio
+async def test_lxmf_cancel_endpoint_calls_message_router(web_cancel_app):
+ message_hash = "aa" * 16
+ aio_app = _build_aio_app(web_cancel_app)
+ async with TestClient(TestServer(aio_app)) as client:
+ response = await client.post(f"/api/v1/lxmf-messages/{message_hash}/cancel")
+ assert response.status == 200
+ body = await response.json()
+ assert body["message"] == "ok"
+ assert body["lxmf_message"] is not None
+
+ web_cancel_app.message_router.cancel_outbound.assert_called_once()
+ called_hash = web_cancel_app.message_router.cancel_outbound.call_args[0][0]
+ assert called_hash == bytes.fromhex(message_hash)
+
+
+@pytest.mark.asyncio
+async def test_lxmf_cancel_endpoint_loads_updated_message_from_database(web_cancel_app):
+ message_hash = "bb" * 16
+ web_cancel_app.database.messages.get_lxmf_message_by_hash.return_value = {
+ "hash": message_hash,
+ "state": "cancelled",
+ "content": "stopped",
+ }
+ aio_app = _build_aio_app(web_cancel_app)
+ async with TestClient(TestServer(aio_app)) as client:
+ response = await client.post(f"/api/v1/lxmf-messages/{message_hash}/cancel")
+ assert response.status == 200
+ body = await response.json()
+ assert body["lxmf_message"]["state"] == "cancelled"
+
+ web_cancel_app.database.messages.get_lxmf_message_by_hash.assert_called_with(
+ message_hash
+ )
diff --git a/tests/frontend/ConversationMessageEntry.test.js b/tests/frontend/ConversationMessageEntry.test.js
new file mode 100644
index 00000000..5514baab
--- /dev/null
+++ b/tests/frontend/ConversationMessageEntry.test.js
@@ -0,0 +1,184 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi } from "vitest";
+import ConversationMessageEntry from "@/components/messages/ConversationMessageEntry.vue";
+
+function makeCv(overrides = {}) {
+ return {
+ hasMessageBubble: () => true,
+ hasFileAttachments: () => false,
+ getParsedItems: () => null,
+ bubbleViewModel: (item) => ({
+ kind: "html",
+ textForRender: item?.lxmf_message?.content || "",
+ singleEmoji: false,
+ showFooter: false,
+ }),
+ renderMarkdown: (text) => text,
+ bubbleMessageBodyFontSizePx: () => 14,
+ shouldHideAutoImageCaption: () => false,
+ isMessageBodyTooLargeForDisplay: () => false,
+ messageBodyCharCount: () => 0,
+ formatTimeAgo: () => "now",
+ getMessageInfoLines: () => [],
+ outboundBubbleSurfaceClass: () => "bubble",
+ outboundBubbleFooterTimeClass: () => "",
+ outboundEmbeddedCardClass: () => "",
+ outboundEmbeddedSecondaryTextClass: () => "",
+ outboundReplySnippetTitleClass: () => "",
+ outboundExpandedActionsShellClass: () => "",
+ outboundMessageMenuButtonClass: () => "",
+ outboundMessageMenuButtonHoverClass: () => "",
+ outboundBubbleDeliveredIconClass: () => "",
+ outboundBubbleSentCheckIconClass: () => "",
+ outboundSendingStatusIconClass: () => "",
+ outboundBubblePendingCheckIconClass: () => "",
+ outboundSentStatusTitle: () => "",
+ outboundBubbleFailedTitle: () => "",
+ outboundBubbleStatusHoverTitle: () => "",
+ isOutboundPendingForUi: (item) => item?.lxmf_message?.state === "sending",
+ isOutboundWaitingBubble: () => false,
+ isOpportunisticDeferredDelivery: () => false,
+ isThemeOutboundBubble: () => false,
+ showRichOutboundPendingUi: () => false,
+ showOutboundTransferProgress: () => false,
+ canCancelOutboundSend: (item) =>
+ item?.is_outbound && ["sending", "outbound", "generating"].includes(item?.lxmf_message?.state),
+ onChatItemClick: vi.fn((item) => {
+ item.is_actions_expanded = !item.is_actions_expanded;
+ }),
+ onMessageContextMenu: vi.fn(),
+ replyToMessage: vi.fn(),
+ deleteChatItem: vi.fn(),
+ showRawMessage: vi.fn(),
+ cancelSendingMessage: vi.fn(),
+ downloadLxmfFileAttachment: vi.fn(),
+ scrollToMessage: vi.fn(),
+ copyOversizedMessageBody: vi.fn(),
+ formatAttachmentSize: () => "1 B",
+ bubbleStyles: () => ({}),
+ isImageOnlyMessage: () => false,
+ pendingOutboundImageSrc: () => "",
+ onOutboundImageClick: vi.fn(),
+ openImage: vi.fn(),
+ imageGroupSortedChron: (items) => items,
+ imageGroupGalleryUrls: () => [],
+ lxmfImageUrl: () => "",
+ expandedMessageInfo: null,
+ ...overrides,
+ };
+}
+
+describe("ConversationMessageEntry wiring", () => {
+ it("shows Cancel send in expanded actions while outbound message is sending", async () => {
+ const chatItem = {
+ type: "lxmf_message",
+ is_outbound: true,
+ is_actions_expanded: true,
+ lxmf_message: {
+ hash: "aa".repeat(16),
+ state: "sending",
+ progress: 10,
+ content: "hello",
+ destination_hash: "bb".repeat(16),
+ source_hash: "cc".repeat(16),
+ fields: {},
+ },
+ };
+ const cv = makeCv();
+
+ const wrapper = mount(ConversationMessageEntry, {
+ props: {
+ entry: { type: "message", key: "m1", chatItem, showTimestamp: true },
+ cv,
+ },
+ global: {
+ mocks: { $t: (key) => key },
+ stubs: {
+ MaterialDesignIcon: { template: "<span />" },
+ MessageReactionsOverlay: true,
+ OutboundTransferProgressFooter: true,
+ },
+ },
+ });
+
+ expect(wrapper.text()).toContain("messages.cancel_send");
+ });
+
+ it("clicking Cancel send calls cv.cancelSendingMessage", async () => {
+ const chatItem = {
+ type: "lxmf_message",
+ is_outbound: true,
+ is_actions_expanded: true,
+ lxmf_message: {
+ hash: "aa".repeat(16),
+ state: "sending",
+ content: "cancel me",
+ destination_hash: "bb".repeat(16),
+ source_hash: "cc".repeat(16),
+ fields: {},
+ },
+ };
+ const cv = makeCv();
+
+ const wrapper = mount(ConversationMessageEntry, {
+ props: {
+ entry: { type: "message", key: "m2", chatItem, showTimestamp: true },
+ cv,
+ },
+ global: {
+ mocks: { $t: (key) => key },
+ stubs: {
+ MaterialDesignIcon: { template: "<span />" },
+ MessageReactionsOverlay: true,
+ OutboundTransferProgressFooter: true,
+ },
+ },
+ });
+
+ const cancelBtn = wrapper.findAll("button").find((b) => b.text().includes("messages.cancel_send"));
+ expect(cancelBtn).toBeDefined();
+ await cancelBtn.trigger("click");
+ expect(cv.cancelSendingMessage).toHaveBeenCalledWith(chatItem);
+ });
+
+ it("file attachment row calls downloadLxmfFileAttachment instead of navigating", async () => {
+ const chatItem = {
+ type: "lxmf_message",
+ is_outbound: false,
+ lxmf_message: {
+ hash: "dd".repeat(16),
+ state: "delivered",
+ content: "",
+ destination_hash: "bb".repeat(16),
+ source_hash: "cc".repeat(16),
+ fields: {
+ file_attachments: [{ file_name: "photo.jpg", file_size: 100 }],
+ },
+ },
+ };
+ const cv = makeCv({
+ hasFileAttachments: () => true,
+ });
+
+ const wrapper = mount(ConversationMessageEntry, {
+ props: {
+ entry: { type: "message", key: "m3", chatItem, showTimestamp: true },
+ cv,
+ },
+ global: {
+ mocks: { $t: (key) => key },
+ stubs: {
+ MaterialDesignIcon: { template: "<span />" },
+ MessageReactionsOverlay: true,
+ OutboundTransferProgressFooter: true,
+ },
+ },
+ });
+
+ const fileBtn = wrapper.findAll("button").find((b) => b.text().includes("photo.jpg"));
+ expect(fileBtn).toBeDefined();
+ expect(fileBtn.attributes("href")).toBeUndefined();
+ await fileBtn.trigger("click");
+ expect(cv.downloadLxmfFileAttachment).toHaveBeenCalledWith(chatItem, 0);
+ });
+});
diff --git a/tests/frontend/ConversationViewer.test.js b/tests/frontend/ConversationViewer.test.js
index 8c61971b..5d86b4ef 100644
--- a/tests/frontend/ConversationViewer.test.js
+++ b/tests/frontend/ConversationViewer.test.js
@@ -6,6 +6,7 @@ import GlobalState from "@/js/GlobalState";
import DialogUtils from "@/js/DialogUtils";
import ToastUtils from "@/js/ToastUtils";
import { MESSAGE_BODY_MAX_DISPLAY_CHARS } from "../../meshchatx/src/frontend/js/messageDisplayLimits.js";
+import DownloadUtils from "@/js/DownloadUtils";
vi.mock("@/js/DialogUtils", () => ({
default: {
@@ -841,6 +842,44 @@ describe("ConversationViewer.vue", () => {
expect(wrapper.vm.chatItems.some((i) => i.lxmf_message?.hash === "pending-abc")).toBe(false);
});
+ it("downloadLxmfFileAttachment fetches attachment bytes and saves through DownloadUtils", async () => {
+ const saveSpy = vi.spyOn(DownloadUtils, "downloadFromApiResponse").mockResolvedValue(undefined);
+
+ const wrapper = mountConversationViewer();
+ const hash = "ff".repeat(16);
+ const chatItem = {
+ type: "lxmf_message",
+ is_outbound: false,
+ lxmf_message: {
+ hash,
+ state: "delivered",
+ content: "",
+ destination_hash: "test-hash",
+ source_hash: "peer-hash",
+ fields: {
+ file_attachments: [{ file_name: "doc.pdf", file_size: 42 }],
+ },
+ },
+ };
+
+ axiosMock.get.mockResolvedValueOnce({
+ data: new ArrayBuffer(3),
+ headers: { "content-type": "application/pdf" },
+ });
+
+ await wrapper.vm.downloadLxmfFileAttachment(chatItem, 0);
+
+ expect(axiosMock.get).toHaveBeenCalledWith(
+ `/api/v1/lxmf-messages/attachment/${hash}/file`,
+ expect.objectContaining({
+ params: { file_index: 0 },
+ responseType: "arraybuffer",
+ })
+ );
+ expect(saveSpy).toHaveBeenCalledWith(expect.objectContaining({ data: expect.any(ArrayBuffer) }), "doc.pdf");
+ saveSpy.mockRestore();
+ });
+
it("calls retrySendingMessage when retry context menu clicked", async () => {
const wrapper = mountConversationViewer();
const failedChatItem = {
diff --git a/tests/frontend/behaviorContracts.test.js b/tests/frontend/behaviorContracts.test.js
new file mode 100644
index 00000000..b75d7816
--- /dev/null
+++ b/tests/frontend/behaviorContracts.test.js
@@ -0,0 +1,113 @@
+import { readFileSync } from "fs";
+import { join } from "path";
+import { describe, it, expect } from "vitest";
+
+function readSource(relativePath) {
+ return readFileSync(join(process.cwd(), relativePath), "utf8");
+}
+
+function readSources(relativePaths) {
+ return relativePaths.map((p) => readSource(p)).join("\n");
+}
+
+describe("behavior contracts: user-visible wiring must stay connected", () => {
+ describe("cancel send", () => {
+ it("ConversationMessageEntry exposes cancel for in-flight outbound messages", () => {
+ const src = readSource("meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue");
+ expect(src).toContain("canCancelOutboundSend");
+ expect(src).toContain("cancelSendingMessage");
+ expect(src).toContain("messages.cancel_send");
+ });
+
+ it("ConversationViewer implements cancelSendingMessage and canCancelOutboundSend", () => {
+ const src = readSource("meshchatx/src/frontend/components/messages/ConversationViewer.vue");
+ expect(src).toContain("async cancelSendingMessage(");
+ expect(src).toContain("canCancelOutboundSend(");
+ expect(src).toContain("/lxmf-messages/${");
+ expect(src).toContain("/cancel");
+ expect(src).toContain("_outboundQueue.cancelJob");
+ });
+
+ it("outbound send jobs carry a cancelKey for queue cancellation", () => {
+ const src = readSource("meshchatx/src/frontend/components/messages/ConversationViewer.vue");
+ expect(src).toContain("cancelKey:");
+ expect(src).toContain("job.cancelled");
+ });
+ });
+
+ describe("downloads", () => {
+ const downloadSurfaces = [
+ ["AboutPage.vue", "meshchatx/src/frontend/components/about/AboutPage.vue"],
+ ["IdentitiesPage.vue", "meshchatx/src/frontend/components/settings/IdentitiesPage.vue"],
+ ["ConversationViewer.vue", "meshchatx/src/frontend/components/messages/ConversationViewer.vue"],
+ ];
+
+ it.each(downloadSurfaces)("%s routes saves through DownloadUtils", (_, relativePath) => {
+ const src = readSource(relativePath);
+ expect(src).toContain("DownloadUtils");
+ });
+
+ it("backup and identity exports do not use browser-only anchor downloads", () => {
+ for (const relativePath of [
+ "meshchatx/src/frontend/components/about/AboutPage.vue",
+ "meshchatx/src/frontend/components/settings/IdentitiesPage.vue",
+ ]) {
+ const src = readSource(relativePath);
+ expect(src).not.toMatch(/link\.setAttribute\(\s*["']download["']/);
+ expect(src).not.toMatch(/link\.click\(\)/);
+ expect(src).not.toMatch(/createObjectURL\(/);
+ }
+ });
+
+ it("chat file attachments do not rely on WebView-unfriendly anchor downloads", () => {
+ const src = readSource("meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue");
+ expect(src).toContain("downloadLxmfFileAttachment");
+ expect(src).not.toMatch(/:download\s*=\s*["']file_attachment\.file_name["']/);
+ expect(src).not.toMatch(/\/attachment\/\$\{chatItem\.lxmf_message\.hash\}\/file\?file_index=\$\{index\}/);
+ });
+
+ it("DownloadUtils supports Android bridge and browser fallback", () => {
+ const src = readSource("meshchatx/src/frontend/js/DownloadUtils.js");
+ expect(src).toContain("MeshChatXAndroid");
+ expect(src).toContain("saveDownload");
+ expect(src).toContain("_triggerBrowserDownload");
+ expect(src).toContain("downloadFromApiResponse");
+ });
+
+ it("Android MainActivity wires WebView downloads and the JS save bridge", () => {
+ const src = readSource("android/app/src/main/java/com/meshchatx/MainActivity.java");
+ expect(src).toContain("setDownloadListener");
+ expect(src).toContain("saveDownload");
+ expect(src).toContain("persistMeshchatDownload");
+ expect(src).toContain("MeshChatXAndroidBridge");
+ });
+ });
+
+ describe("nomad mesh file upload", () => {
+ it("PageNode.add_file always writes binary data", () => {
+ const src = readSource("meshchatx/src/backend/page_node.py");
+ expect(src).toContain('with open(file_path, "wb") as f:');
+ expect(src).not.toMatch(/mode\s*=\s*["']wb["']\s*if\s*isinstance\(data,\s*bytes\)/);
+ });
+
+ it("multipart upload path reaches add_file from meshchat handler", () => {
+ const meshchat = readSource("meshchatx/meshchat.py");
+ expect(meshchat).toContain("async def page_nodes_upload_file");
+ expect(meshchat).toContain("node.add_file(filename, file_data)");
+ });
+ });
+});
+
+describe("behavior contracts: dead API surface", () => {
+ it("cancel endpoint is declared in the HTTP route manifest", () => {
+ const manifest = readSource("tests/backend/fixtures/http_api_routes.json");
+ expect(manifest).toContain('"/api/v1/lxmf-messages/{hash}/cancel"');
+ });
+
+ it("frontend cancel helper is referenced outside its definition file", () => {
+ const viewer = readSource("meshchatx/src/frontend/components/messages/ConversationViewer.vue");
+ const entry = readSource("meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue");
+ expect(entry.match(/cancelSendingMessage/g)?.length ?? 0).toBeGreaterThanOrEqual(1);
+ expect(viewer).toContain("cancelSendingMessage(");
+ });
+});
diff --git a/tests/frontend/downloadWiring.test.js b/tests/frontend/downloadWiring.test.js
new file mode 100644
index 00000000..af0540f3
--- /dev/null
+++ b/tests/frontend/downloadWiring.test.js
@@ -0,0 +1,124 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import AboutPage from "@/components/about/AboutPage.vue";
+import IdentitiesPage from "@/components/settings/IdentitiesPage.vue";
+import DownloadUtils from "@/js/DownloadUtils";
+import ToastUtils from "@/js/ToastUtils";
+
+vi.mock("@/js/DownloadUtils", () => ({
+ default: {
+ downloadFromApiResponse: vi.fn(() => Promise.resolve()),
+ downloadFile: vi.fn(() => Promise.resolve()),
+ downloadFromBase64: vi.fn(),
+ },
+}));
+
+vi.mock("@/js/ToastUtils", () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+describe("download wiring through DownloadUtils", () => {
+ let axiosMock;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ axiosMock = {
+ get: vi.fn().mockImplementation((url) => {
+ if (String(url).includes("/database/backup/download")) {
+ return Promise.resolve({
+ data: new ArrayBuffer(4),
+ headers: { "content-disposition": 'attachment; filename="meshchatx-backup.zip"' },
+ });
+ }
+ if (String(url).includes("/database/backups/")) {
+ return Promise.resolve({
+ data: new ArrayBuffer(8),
+ headers: {},
+ });
+ }
+ if (String(url).includes("/identity/backup/download")) {
+ return Promise.resolve({
+ data: new ArrayBuffer(2),
+ headers: {},
+ });
+ }
+ return Promise.resolve({ data: {}, headers: {} });
+ }),
+ post: vi.fn().mockResolvedValue({ data: {} }),
+ delete: vi.fn().mockResolvedValue({ data: {} }),
+ };
+ window.api = axiosMock;
+ window.electron = {
+ getMemoryUsage: vi.fn().mockResolvedValue(null),
+ electronVersion: vi.fn().mockReturnValue("1.0.0"),
+ chromeVersion: vi.fn().mockReturnValue("1.0.0"),
+ nodeVersion: vi.fn().mockReturnValue("1.0.0"),
+ appVersion: vi.fn().mockResolvedValue("1.0.0"),
+ };
+ });
+
+ afterEach(() => {
+ delete window.api;
+ delete window.electron;
+ });
+
+ it("AboutPage.backupDatabase saves through DownloadUtils instead of anchor click", async () => {
+ const wrapper = mount(AboutPage, {
+ global: {
+ mocks: { $t: (key) => key },
+ stubs: { MaterialDesignIcon: true },
+ },
+ });
+
+ await wrapper.vm.backupDatabase();
+
+ expect(axiosMock.get).toHaveBeenCalledWith(
+ "/api/v1/database/backup/download",
+ expect.objectContaining({ responseType: "arraybuffer" })
+ );
+ expect(DownloadUtils.downloadFromApiResponse).toHaveBeenCalledWith(
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ "content-disposition": expect.stringContaining("meshchatx-backup.zip"),
+ }),
+ }),
+ "meshchatx-backup.zip"
+ );
+ });
+
+ it("AboutPage.downloadBackupFile saves through DownloadUtils", async () => {
+ const wrapper = mount(AboutPage, {
+ global: {
+ mocks: { $t: (key) => key },
+ stubs: { MaterialDesignIcon: true },
+ },
+ });
+
+ await wrapper.vm.downloadBackupFile("auto-backup.zip");
+
+ expect(DownloadUtils.downloadFromApiResponse).toHaveBeenCalledWith(
+ expect.objectContaining({ data: expect.any(ArrayBuffer) }),
+ "auto-backup.zip"
+ );
+ expect(ToastUtils.success).toHaveBeenCalled();
+ });
+
+ it("IdentitiesPage.downloadIdentityFile saves through DownloadUtils", async () => {
+ const wrapper = mount(IdentitiesPage, {
+ global: {
+ mocks: { $t: (key) => key },
+ stubs: { MaterialDesignIcon: true, LxmfUserIcon: true },
+ },
+ });
+
+ await wrapper.vm.downloadIdentityFile();
+
+ expect(DownloadUtils.downloadFromApiResponse).toHaveBeenCalledWith(
+ expect.objectContaining({ data: expect.any(ArrayBuffer) }),
+ "identity"
+ );
+ });
+});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────